fix(falkordb): make the graph-DB push converge, batch its writes, add a repo-keyed delta (#3057) - #3069
Conversation
… a repo-keyed delta (Graphify-Labs#3057) `push_to_falkordb` is MERGE-only, so once `graphify global add` prunes a repo out of the global graph, a full re-push leaves every one of those nodes in the target permanently: the database diverges from graph.json and never converges back. @Azeem1985 measured +1,250 nodes / +1,151 edges of surplus on a 75k-node global graph, with 25 of 25 sampled pruned ids still present after a full re-push. The same code path also sent one query per node and one per edge, and matched edge endpoints with a label-free `MATCH (a {id: $src})` that no index can serve. On 20 repos / 20k nodes a full push took 79.7s; batched UNWIND against an indexed `:Entity` label takes 2.9s — 27x — and the labelling change is what makes the index reachable at all, since nodes previously carried only their file-type label. What is new: - Batched UNWIND writes, an `:Entity` label indexed on `id` alongside the existing file-type label, and a one-time backfill so MERGE adopts nodes written by an older graphify instead of duplicating them. This is also @kingjin94's Graphify-Labs#2258 diagnosis. - `--graph-name`. The parameter always existed on the writer; the CLI never passed it, so every CLI push landed on the `graphify` key with no way to aim it elsewhere. That is what turned a mistake into a 1.2M-node deletion. - `--prune` converges the target: nodes AND edges. DETACH DELETE takes a pruned node's edges with it, but an edge dropped between two surviving endpoints needs its own sweep, which is the surplus-edge half of the report. Opt-in, because `--graph-name` never existed and some `graphify` keys hold several projects merged together, where a converging default would either silently delete or hard-refuse on a routine re-push. - An add-only push now reports the drift it cannot fix, so the divergence stops being silent even when nobody passes `--prune`. - Repo-keyed delta (`graphify global push`, default). Mirrors global_add's own contract: the repo is the unit of change, keyed on the manifest source_hash global_add already records and already uses for its own skip. 5.0% of the rows for a 1-of-20-repo change; 0 rows when nothing moved. - Drift repair. Push state lives in the target as :GraphifyPushState nodes, cross-checked against the target's own per-repo counts. A local ledger still reads clean after a wipe or a half-landed run and never repairs it. - Cross-repo edges survive a delta. global_add remaps external-library nodes onto whichever repo first contributed them, so a B->A edge is owned by neither alone; the delta re-sends every edge incident to a rewritten repo, not only its internal ones. - Deletes are capped at 20% of the target unless `--allow-shrink` (the Graphify-Labs#479 rule applied to the push) and paged with the LIMIT inside a WITH, because FalkorDB's LIMIT does not short-circuit an eager DELETE. - `graphify global push`. `export --push` resolves its source from a project output directory, so the global graph had no CLI push route at all. The Neo4j writer is untouched — same defects, but a fix cannot be verified without a Neo4j instance. The new flags are refused on `export neo4j` rather than silently ignored, since accepting `--prune` there would report a converged push that converged nothing. 15 new integration tests against a live FalkorDB; they fail on this commit's parent and pass here. No CHANGELOG entry — this repo folds those at release. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Graphify reviewed this change.
Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.
Formal verification. No changes could be formally verified in this run.
Graphify review — findings
Adds FalkorDB push support with a --graph-name target selector (default graphify), --prune to mirror deletions, and --allow-shrink to override the prune size guard, wiring these through graphify export falkordb and reporting pruned counts plus a "target has surplus nodes" note when pushing without prune. Adds graphify global push <URI>, which is delta by default (re-sending only repos whose source or target count drifted) and takes --full/--prune to re-send everything and converge the target. Rejects --graph-name/--prune/--allow-shrink on the Neo4j path rather than silently ignoring them, and surfaces the prune size guard as a ValueError that exits non-zero.
Worth a look
- Delta mode ignores repos present in manifest but absent from target —
graphify/exporters/graphdb.py· Escalate · high- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- push_to_falkordb node labels changed from per-file-type to fixed :Entity, breaking existing graph queries —
graphify/exporters/graphdb.py· Escalate · high- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Global push accepts database password on the command line —
graphify/cli.py:3028· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- removed repos delete nodes but not their :State stamp counted correctly / edges lost across repos —
graphify/exporters/graphdb.py· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Push epoch can collide for concurrent pushes in the same millisecond —
graphify/exporters/graphdb.py:49· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 367 functions depend on the 170 functions this change touches.
Health — this change adds coupling hotspots:
- new:
dispatch_command()— 2 callers, 123 callees - new:
push_to_falkordb()— 20 callers, 8 callees - new:
global_add()— 10 callers, 8 callees - new:
_stale_graph_sources()— 7 callers, 6 callees - new:
_run_hook_guard()— 4 callers, 7 callees - new:
global_remove()— 5 callers, 5 callees - new:
_push_delta()— 1 callers, 8 callees - new:
test_poisoned_manifest_is_healed()— 0 callers, 6 callees
Verification — 367 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 320 function(s) in the blast radius were not formally verified this run
Formal verification
Could not verify: Could not verify dispatch\_command.
The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)
Could not verify: Could not verify push\_to\_falkordb.
The verifier did not have enough to check push\_to\_falkordb, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly ImportError — names the real obstacle, not a sampling gap)
· 2 grounded finding(s) anchored inline below; 6 more finding(s) on lines outside this diff (see the check run).
| return n | ||
|
|
||
|
|
||
| def _push_delta( |
There was a problem hiding this comment.
_push_delta()
fans out to 8 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| } | ||
|
|
||
|
|
||
| def push_to_falkordb( |
There was a problem hiding this comment.
push_to_falkordb()
fans out to 8 callees (efferent coupling); 20 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
|
Independent verification from the box that produced the #3057 numbers — same rig that reviewed the Setup: clean clone of Test suite: 17/17 pass in ~1.3s (15 new + 2 pre-existing). Negative control — the same test file against the parent commit: 15 failed / 2 passed, i.e. every one of the 15 new tests fails on the parent. That is one more than the PR's own "14 of 15"; same counting difference as on the fork review — a test that dies on a then-unknown kwarg is the test catching the missing API, so I count it as failing honestly. CLI placement, since the fork thread reported a near-miss where the
Neo4j refusal: I did not re-run the 20-repo/20k-node perf corpus, so I can't independently confirm the 27x — but the shape of the claim is right: once a branch's own writes are batched, the exporter is the last per-row path standing, and v8 had never batched it. This matches the contract as reviewed on the fork thread — |
|
Following up the verification run above — I also executed the two "Delta mode ignores repos present in manifest but absent from target" — "push_to_falkordb node labels changed from per-file-type to fixed :Entity, breaking existing graph queries" — the writer keeps both labels: Re-ran the four relevant tests just now against a scratch FalkorDB on this head (the two label tests, The three |
|
Thanks — and you are right about the count, so I have corrected the PR description rather than leave a number in it that I cannot reproduce. 15 failed / 2 passed against the parent, confirmed here too. The cause of my "14 of 15" is boring and entirely mine: I ran the negative control before adding the drift-notice test you asked for on the fork thread, and never re-ran it after. So the 15th test existed by the time I wrote the description but had never been measured against the parent. It fails there like the rest — That is the second time your counting has been right and mine stale on the same axis, which suggests my habit of measuring the negative control once and then continuing to edit the test file is the actual defect. Re-running it as the last step before writing the description is the fix. On the perf corpus you did not re-run: entirely reasonable not to, and the claim does not need to carry the review. For the record, the two branches measured 4.1s -> 0.4s and 79.7s -> 2.9s for the same 20-repo / 20k-node push, and your reading of why is exactly right — the other branch had already batched its own writes, so its exporter was a smaller share of the total. Anyone who wants to check it needs only a scratch key and the loop in the description; nothing in the correctness argument rests on it. Everything else you exercised — provider/global routing, the delta skip and selective re-send, the read-back showing the stale id gone and the |
|
Description edit confirmed — it now reads all 15 failing against the parent, with the reproduction credited. That settles the only number that was open. Agreed on the habit being the real defect: a negative control measured once while the test file keeps moving is the same class of error as quoting a benchmark from an earlier commit, and re-running it as the last step before writing the description is the mechanism that makes it impossible rather than unlikely. Thanks for putting the perf pair on the record — 79.7s → 2.9s here vs 4.1s → 0.4s on the branch that had already batched its own writes is exactly the split the "exporter was the last per-row path standing" reading predicts, so the 27x now carries both a reproducible loop and a coherent mechanism, even if nobody re-runs it. Nothing outstanding from my side either — from the reporter's seat, #3057 closes when this merges. |
Fixes #3057. Targets
v8directly — this is the upstream-mergeable version of the fix; see the note at the bottom.The defect
push_to_falkordbis MERGE-only. Oncegraphify global addprunes a repo out of the global graph, a full re-push leaves every one of those nodes in the target permanently —graph.jsonand the database diverge and never converge back, no matter how many times you re-push. @Azeem1985 measured it: +1,250 nodes / +1,151 edges of surplus on a 75k-node global graph, and 25 of 25 sampled pruned ids still present after a full re-push.While fixing it, a second problem surfaced that is not in the report. Measured here, 20 repos / 20k nodes / 20k edges:
The old path sent one query per node and one per edge, and matched edge endpoints with a label-free
MATCH (a {id: $src})that no index can serve — because nodes carried only their file-type label (:Python), there was no shared label to index. That is @kingjin94's #2258 diagnosis, and it is fixed here by giving every node:Entityalongside its file-type label and indexingn.id.What changed
Batched writes on an indexed shared label.
UNWINDin 1000-row batches,MERGE (n:Entity {id: row.id}) SET n:Python SET n += row, withCREATE INDEX FOR (n:Entity) ON (n.id). The file-type label is kept, not replaced, so existing queries keyed on it still work. A one-time backfill (MATCH (n) WHERE n.id IS NOT NULL AND NOT n:Entity SET n:Entity) adopts nodes written by an older graphify — without it, the MERGE switch would create a duplicate beside every existing node. There is a test asserting both labels survive.--graph-name. The writer has always takengraph_name; the CLI never passed it, so every push through the CLI landed on thegraphifykey with no flag to send it elsewhere. You could not rehearse a push, run a staging graph beside production, or point two environments at one instance without dropping to Python. This is also what turned @Azeem1985's mistake into a 1.2M-node deletion.--pruneconverges the target — nodes and edges.DETACH DELETEon a stale node takes its edges with it, but an edge dropped between two endpoints that both survive needs its own sweep; without it the surplus-edge half of the report lives on. Opt-in rather than default:--graph-namenever existed, so somegraphifykeys genuinely hold several projects merged together, and a converging default would either silently delete the others (under the guard) or hard-refuse a routine re-push (over it). Neither is acceptable in a patch upgrade — @Azeem1985's framing, and it is better than my original reasoning.An add-only push reports the drift it cannot fix. The core complaint in #3057 is that the divergence is silent, and that stays true on the default path if the only fix is a flag nobody passes. So a non-pruning push now counts the nodes it did not stamp — the same query the prune path deletes by, so there is one definition of drift rather than two that can disagree — and the CLI prints one line:
Repo-keyed delta, via the new
graphify global push(delta by default). This mirrorsglobal_add's own contract instead of inventing one: the repo is already its unit of change — it prunes a repo whole, re-adds it whole, records a per-reposource_hash, and already returnsskipped=Truewhen that hash has not moved. The push now consumes the same signal.Delta output is identical to a from-scratch load on node and edge counts.
Drift repair. Push state lives in the target as
:GraphifyPushStatenodes and is cross-checked against the target's own per-repo counts (one aggregate). A local ledger still reads clean after the database is wiped or a run half-lands, and the delta would skip forever — @Azeem1985's finding #2. There is a test that deletes part of a repo behind the push's back, leaves the hashes untouched, and asserts the repo is rebuilt.Cross-repo edges survive a delta.
global_addremaps external-library nodes onto whichever repo first contributed them, so aB -> Aedge is owned by neither repo alone. Pruning A drops it, and re-adding only A's internal edges would erode cross-repo connectivity a little on every delta — silently. The delta buckets edges by either endpoint's repo. Pinned by a test.Delete guards. Capped at 20% of the target unless
--allow-shrink— the same "refuse to SILENTLY drop nodes" rule as the #479 build guard. Only net removal counts: a re-pushed repo is pruned and immediately re-added, so charging it would refuse any delta touching more than 20% of a small global graph; a repo that comes back smaller is charged the difference, which is what catches "the manifest says 2 nodes, the target holds 50,000". Deletes are paged with theLIMITinside aWITH, because FalkorDB'sLIMITdoes not short-circuit an eagerDELETE—... DETACH DELETE n LIMIT $pagedeletes the whole label. That is @Azeem1985's finding #1, and the reason sits in a comment at the call site.graphify global push.export --pushresolves its source from a project's output directory, so the global graph — the one graph this entire contract is about — had no CLI push route at all.Convergence, measured
Tests
15 new integration tests against a live FalkorDB: both labels, add-only default, drift reporting, node convergence, edge-only convergence, prune idempotence, the guard refusing and not deleting, target isolation by
--graph-name, delta skip / selective re-send / repo removal, cross-repo edge preservation, drift repair, and a foreign manifest being refused.Verified they catch the bugs rather than just passing: all 15 fail on this commit's parent, all pass here (15 failed / 2 passed against the parent; the 2 are the pre-existing create/idempotence tests). Independently reproduced by @Azeem1985 on the rig that produced the original #3057 measurements. Full suite is +15 passing with no new failures (this environment has 557 pre-existing failures from missing tree-sitter grammars, identical before and after).
Deliberately not included
The Neo4j writer is untouched — byte-identical to the parent, verified by AST comparison. It has the same defects (per-row, unindexed, never deletes, no
databaseparameter), but a fix cannot be verified without a Neo4j instance. The new flags are refused onexport neo4jrather than silently ignored: accepting--prunethere and doing nothing would report a converged push that converged nothing — the same class of lie the missing-label bug told. Follow-up when there is an instance to test against.No CHANGELOG entry — this repo folds those at release (e.g. 1c6b3db), per @Azeem1985.
Streaming
graph.jsoninto the push instead of loading it through NetworkX is not in here. It is a real problem (@Azeem1985 reports a 1.87GB graph peaking ~5.3GB RSS and getting OOM-killed before a row went out), but it is a separate change to how export loads graphs and belongs in its own PR, the way it was originally offered.Note on provenance
This work was first written against a FalkorDB-backend branch where
graphify/store.pyprovides aGraphStorewith a batched writer, and reviewed there by @Azeem1985 on the box that produced the original #3057 numbers (review). That version is not portable tov8, which has nostore.pyat all — every symbol it leaned on is absent, so a cherry-pick auto-merges four of five files and then ImportErrors on first use. This PR is thev8-native implementation of the same contract: the batched writer is hand-rolled ingraphdb.py, and the delta buckets the in-memory graph by repo in two passes rather than querying the source. Same tests, same guards, same measured behaviour.Relationship to #2312
#2312 proposes a Neo4j source-of-truth backend. This is not competing with it — it is the push-side piece that design needs regardless, and it is small enough to land independently.